YownYang's blog

译《Effective Objective-C 2.0》第六章

这是翻译《Effective Objective-C 2.0》的第六章:Block和GCD


PS:不使用中文名字进行翻译

block : block/Block

GCD : Grand Central Dispatch


简介

多线程开发是每个开发者都会遇到并需要考虑的情况。即使你不认为你的程序是多线程的,但其实它是,因为系统框架通常会在主线程之外做一些事情。最坏的情况就是UI线程被阻塞,应用程序挂起。在Mac OS X上,会有一个彩球一直转;在iOS上,如果阻塞过久,你的程序可能会终止。

幸运的是,苹果已经解决多线程的问题了。现代多线程的核心功能是block和GCD。从技术上讲,它们明显没什么关系,但它们是一起引入的。block提供了词法闭包,这对C、C++、Objective-C都是非常有用的,主要是提供了一种机制,让代码像对象一样传递,并运行在不同的环境下。重要的是,block可以使用它定义范围内的任何事物。

GCD是block的关联技术,基于派发队列概念提供线程的抽象。block可以被排进队列,GCD可以处理所有的事务。GCD会根据系统资源的情况,对每个队列进行创建,暂停,销毁等操作。而且,GCD可以提供很多易于使用的常见功能,例如单一线程代码安全执行,基于系统资源的并发。

现在的Objective-C项目中,block和GCD是主要部分之一。因此,你需要理解它们的功能以及工作原理。

理解Block

block提供闭包。这项语言特性是作为GCC编译器的扩展,因此适用于所有的现在Clang版本(Clang是开发Mac OS X和iOS的编译器)。而运行期组件则需要Mac OS X 10.4和iOS 4.0之后才能支持block。语言功能是C级别的功能,因此只要运行期组件支持C、C++、Objective-C、Objective-C++的代码都可以使用block。

block的基础

block跟函数很相似,但它定义在另一个函数的内部,和另一个函数更享作用域。block使用^符号作为标示,后面跟随一个作用域,作用域包含block的实现。例如,一个简单的block如下:

1
2
3
^{
// Block implementation here
}

block就是一个值,它也有自己的类型。就像int、float或者Objective-C对象,也可以把block赋值给一个变量,然后就像使用其他变量那样使用它。这种block类型语法跟函数指针很像。下面是一个简单block的例子,它没有参数和返回值:

1
2
3
void(^someBlock)() = ^{
// Block implementation here
};

这个block定义了一个叫做someBlock的变量。它可能看起来是奇怪的,因为它的变量名写在中间,但是一旦你理解了它的语法,它是非常易读的。block的结构语法如下:

1
return_type (^block_name)(parameters)

定义一个block,它有两个int类型参数,返回值也是int类型,你会使用下面的语法:

1
2
3
4
int (^addBlock)(int a, int b) = ^(int a, int b) {
return a+b;
};

block的使用就像函数一样。例如,addBlock可以这样使用:

1
int add = addBlock(2, 5); //<add = 7

block还有一个强力的功能,就是允许访问它定义区域的变量。意思是任何适用于block定义范围的变量都适用于block内部。例如,你可以定义一个block然后使用另一个变量:

1
2
3
4
5
6
7
int additional = 5;
int (^addBlock)(int a, int b) = ^(int a, int b){
return a+b+additional;
};
int add = addBlock(2, 5); //<add = 12

默认情况下,被block捕捉的变量不能在block中修改。在上面的例子中,如果在block内部对additional变量进行修改,编译器将会编译错误。但是,可以在声明变量时使用特定字符__block修饰,这样就可以修改了。例如,block可以使用数组枚举去确定数组中多少元素小于2:

1
2
3
4
5
6
7
8
NSArray *array = @[@0, @1, @2, @3, @4, @5];
__block NSInteger count = 0;
[array enumerateObjectsUsingBlock:^(NSNumber *number, NSUInteger idx, BOOL *stop) {
if ([number compare:@2] == NSOrderedAscending) {
count++;
}
}];
// count = 2

这个例子也展示了如何使用内连block。这个block通过enumerateObjectsUsingBlock:方法声明为一个内连block,直接调用,并未赋值给一个本地变量。这种常用的代码模式展示了为什么block是非常有用的。在block成为语言的一部分之前,枚举方法只能调用函数指针或者选择器名字。状态不得不通过手动处理,通常使用不透明指针,因此需要额外的代码,并将它们分散在别的地方。声明一个内连block则可以使代码逻辑集中在一个地方。

当block捕捉一个对象类型的变量,它会偷偷的保留它。当block自身被释放时,这个变量才会释放。这就引出一个关于block的重大问题。一个block可以被看做一个对象。实际上,block可以像其他Objective-C对象一样响应许多选择器。重要的是block自身就像其他对象一样,它也有引用计数。当block最后的应用被移除时,block就会被销毁。因此,block捕捉的对象会在这时进行释放,以平衡捕捉时增加的引用计数。

如果block作为一个实例变量定义在一个Objective-C类中,那么可以使用self变量以及类的所有变量。使用实例变量时也永远不需要加上__block前缀。如果一个实例变量被block通过读或者写的方法捕获,它也会捕获self变量,因为实例变量与self持有的实例变量相关联。例如,一个叫做EOCClass的类里面有一个方法,方法里面使用一个block:

1
2
3
4
5
6
7
8
9
10
11
@interface EOCClass
- (void)anInstanceMethod {
// ...
void (^someBlock)() = ^{
_anInstanceVariable = @"Something";
NSLog(@"_anInstanceVariable = %@", _anInstanceVariable);
};
// ...
}
@end

这个EOCClass的实例变量有一个叫做anInstanceMethod的方法,这个方法运行时会使用到self变量。你会非常容易忘记self变量被block捕捉了,因为代码中没有显示调用self。但是,访问实例变量与下面语法相同:

1
self->_anInstanceVariable = @"Something";

这就是为什么self变量会被捕捉。通常情况下,使用属性访问实例变量,在这种情况下,就要使用self变量了:

1
self.aProperty = @"Something";

但是,记着self是一个对象,当block捕捉了self,它会被保留。如果self也持有block,这种情况很容易造成循环引用。更多信息看第40节。

block的内部结构

Objective-C中,每一个对象都有一块固定的内存区域。每个对象的内存区域大小是不一样的,这取决于实例变量的个数和关联的数据。一个block也是一个对象,因此它内存区域的第一个变量是一个指向类的指针,叫做isa指针(看第14节)。block的其余内存里面包含各种它正常运行的信息。图6.1展示了block的细节。


Figure 6.1 一个block对象的内存分布。

在这个布局里面最重要的是那个叫做invoke的变量,它是一个函数指针,指向block的实现。函数原型至少有一个void*,它代表块本身。之前说过,block仅是替代函数指针的,之前使用函数指针时,通过使用不透明指针传递状态。而使用block之后,则可把C语言特性改为简单易用的接口。

descriptor变量是每一个block都会有的结构体的指针,声明了block对象的大小,函数指针的拷贝和释放方法。当block的拷贝和释放呗调用时就会运行这些方法,例如,将捕获的对象保留或者释放。

最后,block包含它所捕获的变量的拷贝。这些拷贝存储在descriptor变量后面,捕获多少变量就占用多少空间。请注意,拷贝的并不是这些对象本身,而是指向这些对象的指针变量。当block运行时,被捕捉的变量从内存中被读取,这就是为什么block需要通过参数传递进invoke函数。

全局block、堆block、栈block


PS : 经我本人验证,下面的block并不会像书中一样,出了if或者else的区域就被释放,ARC/MRC我都试过。所以各位看书的时候,请自行实验。但是除了例子举的有问题外,别的并无问题。个人看法,欢迎讨论。


当定义block时,它初始化的内存地址是在栈上。这意味着该block仅在它定义的范围内有效。例如,下面的代码是有风险的:

1
2
3
4
5
6
7
8
9
10
11
void (^block)();
if ( /* some condition */ ) {
block = ^{
NSLog(@"Block A");
};
} else {
block = ^{
NSLog(@"Block B");
};
}
block();

两个block定义在ifelse判断里面,初始化在栈内存上。当每个block初始化栈内存后,编译器都会在内存作用域的最后对它们进行释放。所以每个block仅在它们所在的判断语句内是有效的。这段代码没有任何编译错误,但是在运行时可能出现函数错误。如果被释放的内存没有被重写,代码不会有错误,但是如果重写了,程序将会崩溃。

为了解决这个问题,可以在对block进行拷贝。这样做的原因是可以将block从栈区拷贝到堆区。一旦将其拷贝到堆区,block就可以在它定义区域外使用了。而且,一旦将它拷贝到堆区,block也拥有引用计数了。后续的拷贝操作都不会再真的拷贝只是增加block的引用计数。当一个堆block不再被持有,它需要被释放,如果使用ARC,它会自己释放,如果使用MRC,就手动调用release。当它的引用计数为0时,堆block就像对象一样被释放。栈block不需要手动释放,因为栈的内存是由系统自动释放的,刚才那段代码之所以会有问题就是因为这个原因。

现在,你可以简单的对其使用copy方法使其代码安全:

1
2
3
4
5
6
7
void (^block)();
if ( /* some condition */ ) {
block = [^{ NSLog(@"Block A");} copy];
} else {
block = [^{ NSLog(@"Block B");} copy];
}
block();

现在代码是安全的了。如果使用了MRC,需要在最后释放block。

全局block是另一个概念,它不同于堆block和栈block。全局block不捕捉任何状态,例如外围的变量,运行时也不需要状态参与。它的内存地址是在编译时就可以确定的;所以全局block是声明在全局内存中的而不是每次使用都从栈中创建。另外,对全局block使用拷贝也是无效的,因为一个全局block不会被释放。所以这样的block实际上就是一个单例。下面是一个全局block:

1
2
3
void (^block)() = ^{
NSLog(@"This is a block");
};

全局block的所有信息都必须在编译时就确定。这是一种优化技术;如果简单的block还需要在栈上和堆上做拷贝或释放,等于多做一些无用的操作。

小结

  • block是一种适用于C、C++、Objective-C的语义闭包。
  • block参数和返回值都是可选的。
  • block可以在栈上、堆上、全局初始化。一个栈block可以拷贝到堆上面,这样它就会像其余的Objective-C对象一样,拥有自己的引用计数。

使用typedefs创建通用block

block具有固有类型;因而,可将其赋给适当的类型变量。block的类型由参数和返回值组成。例如,下面的block:

1
2
3
4
5
6
7
^(BOOL flag, int value){
if (flag) {
return value * 5;
} else {
return value * 10;
}
}

这个block有两个参数,分别是Bool型和int型,以及一个int型的返回值。如果要将其赋给一个变量,这个block需要适当的类型。赋值变量的类型是像这样的:

1
2
3
4
int (^variableName)(BOOL flag, int value) = ^(BOOL flag, int value) {
// Implementation
return someInt;
}

这个看起来跟正常类型由很大差别,但如果你使用函数指针,你会感觉到熟悉。这个类型如下:

1
return_type (^block_name)(parameters)

block变量的定义与普通类型是不同的,它的变量名在中间而不是在右边。这造成它的语法难以记忆和阅读。因此,有一个好办法是为通用block定义类型,特别是你给别人提供API时。你可以起个好读的名字表示block的用途并将它的类型隐藏在后面。

为了隐藏block的复杂类型,你可以使用C语言的一个功能去定义类型。这个功能的关键字是typedef,使用它可以定义一个易于阅读的名字,使它成为类型别名。例如,使用类型定义给一个block定义新的类型,这个block接受一个int参数和一个Bool参数并返回一个int值:

1
typedef int(^EOCSomeBlock)(BOOL flag, int value);

就像之前block变量的命名一样名字在^符号右边,新的类型名字也是这样。这条语句向系统增加了一个叫做EOCSomeBlock的类型。所以当你再次创建同类型变量时,你可以使用这个新类型:

1
2
3
EOCSomeBlock block = ^(BOOL flag, int value){
// Implementation
};

现在这代码就易于阅读了,它就像你平时使用的变量一样定义,类型在左,变量名在右。

使用这个功能可以把API中的block做的更为易用。类里面有些方法可能需要使用一个block参数,例如,一个异步任务完成时的回调,使用这个功能可以使得代码易于阅读。考虑一个情况,一个类有一个叫做start的方法,它有一个在任务结束时调用的block参数。如果没有使用类型定义,这个方法可能是这样的:

1
- (void)startWithCompletionHandler:(void(^)(NSData *data, NSError *error))completion;

注意,在方法中的block参数和block变量的语法是不同的。如果方法中block参数可以是一个单词,那么就易读多了。所以你可以定义一个类型信息并且代替它:

1
2
typedef void(^EOCCompletionHandler)(NSData *data, NSError *error);
- (void)startWithCompletionHandler:(EOCCompletionHandler)completion;

这样的参数是容易阅读和理解的。更好的是,现在的IDE都是自动支持这种类型定义的,这使得它更易于使用。

如果你想重新构建block的函数时,使用类型定义也是非常有用的。例如,如果你现在想给block添加一个参数,你只需要简单修改类型定义:

1
typedef void(^EOCCompletionHandler)(NSData *data, NSTimeInterval duration, NSError *error);

任何使用了这个类型定义的地方,例如方法签名,都会编译失败,然后你可以逐个修复。如果没有类型定义,你需要逐个找到你需要修改的代码。这很容易遗忘一两处,导致难以排查的bug。

通常在使用block类型定义的类中定义它们。也会给类型block添加类名作为前缀。这使得block的用途非常清楚。还可以使用typedef定义更多的别名。这是多多益善的。

在Mac OS X和iOS中的Account框架是有这种例子的。在框架中可以找到下面两个类型定义:

1
2
typedef void(^ACAccountStoreSaveCompletionHandler) (BOOL success, NSError *error);
typedef void(^ACAccountStoreRequestAccessCompletionHandler) (BOOL granted, NSError *error);

这两个block都相同的签名,但使用在不同的场景。签名中的类型名字和参数名字可以让开发者很容易理解这个block怎么使用。也可以将这两个block定义为一个单一类型定义,可能统称ACAccountStoreBooleanCompletionHandler,在两个地方都适用它。但是,这样做会使得block的用途不是那么清楚了。

相似的,如果你有几个类都执行相似但不同的异步任务,并且无法放在一个继承中,那么每个类都应该有它自己的block类型定义。可能每一个block的签名都是相同的,但那也好过让每个类都适用同一个block。另一方面,如果这些类可以从一个基类继承,你可以将block类型定义放在基类中,然后让每个子类使用它。

小结

  • 使用类型定义可以使得block变量更易于使用。
  • 定义block名字时务必遵守当前规则,使其不与其他类型冲突。
  • 给相同签名block定义多个别名。当你想重构的代码使用了其中某个别名,只需修改相应typedef中的签名,无须改动其他别名。

使用block去减少代码分离

在编写项目时,有一个常用的范例是用户接口需要去执行异步任务。这样当执行长时间的任务时,不会阻塞用户界面的显示和触摸所用的线程,例如文件I/O或者网络请求。这个线程就是我们提到的主线程。如果执行任务的线程是同步的,当任务执行时,用户界面的任何操作都无法响应。在某些情况下,如果应用停止响应一段时间,它将会被自动终结。这是真实存在于iOS应用的;如果主线程长时间被阻断,系统监视器会终结应用。

异步方法需要用过某种方式去通知相关代码任务执行完毕。这有很多实现办法。通用的一个办法就是设计一个协议,让某个对象遵守它。这个对象成为被委托者后就可以在结束时得到通知了,例如一个异步任务的完成。

考虑下面这个从URL获取数据的类。使用委托模式,这个类大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#import <Foundation/Foundation.h>
@class EOCNetworkFetcher;
@protocol EOCNetworkFetcherDelegate <NSObject>
- (void)networkFetcher:(EOCNetworkFetcher*)networkFetcher
didFinishWithData:(NSData*)data;
@end
@interface EOCNetworkFetcher : NSObject
@property (nonatomic, weak) id <EOCNetworkFetcherDelegate> delegate;
- (id)initWithURL:(NSURL*)url;
- (void)start;
@end

某各类可能会这样使用这种API:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)fetchFooData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"];
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
fetcher.delegate = self;
[fetcher start];
}
// ...
- (void)networkFetcher:(EOCNetworkFetcher*)networkFetcher didFinishWithData:(NSData*)data {
_fetchedFooData = data;
}

这种方法也不是有问题。但是,block可以使得代码非常清晰。block可以使API变得更加紧凑,并且让使用者更容易使用。这需要定义一个block类型,将其作为完成handler,然后作为参数直接传给start方法:

1
2
3
4
5
6
7
8
9
#import <Foundation/Foundation.h>
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;
@end

这种做法跟使用委托协议是很像的,但是它在start方法中直接加入了内联block,这可以提升代码的阅读性。例如,考虑使用block风格的API:

1
2
3
4
5
6
7
- (void)fetchFooData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"];
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data){
_fetchedFooData = data;
}];
}

对比委托和block两种方式的代码会发现block这种代码更简洁。异步执行完毕后的逻辑代码和开始任务的代码都在一起。而且,由于block定义在网络获取器的范围内,你可以访问这个范围内的所有变量。在这个简单的例子中,优势不是很明显,但在复杂场景下,就可以看到优势了。

委托模式有一个缺点,就是如果一个类使用了多个网络请求去下载不同的数据,它需要在委托方法中根据不同的网络下载器去处理不同情况。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)fetchFooData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"];
_fooFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
_fooFetcher.delegate = self;
[_fooFetcher start];
}
- (void)fetchBarData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/bar.dat"];
_barFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
_barFetcher.delegate = self;
[_barFetcher start];
}
- (void)networkFetcher:(EOCNetworkFetcher*)networkFetcher didFinishWithData:(NSData*)data {
if (networkFetcher == _fooFetcher) {
_fetchedFooData = data;
_fooFetcher = nil;
} else if (networkFetcher == _barFetcher) {
_fetchedBarData = data;
_barFetcher = nil;
}
// etc.
}

这种做法会使得委托方法的回调代码变长,网络下载器必须存储为一个实例变量。这样做可能是因为别的原因,例如稍后取消下载,但是它终究会使得类的代码激增。这就是block的优势了,它不需要存储网络获取器,也不需要去做切换。每个回调block的逻辑是定义在每个网络获取器那里的,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)fetchFooData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"];
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data){
_fetchedFooData = data;
}];
}
- (void)fetchBarData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/bar.dat"];
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data){
_fetchedBarData = data;
}];
}

这种写法还可以进行扩展,许多现在的基于block的API还有一个用于处理错误的block。有两个方法可以做到这点。第一个是将成功的block与失败的block分开实现。第二个是将两种情况写在一个block里面。使用分开情况大概是这样的:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>
@class EOCNetworkFetcher;
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
typedef void(^EOCNetworkFetcherErrorHandler)(NSError *error);
@interface EOCNetworkFetcher : NSObject
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion failureHandler:(EOCNetworkFetcherErrorHandler)failure;
@end

使用这种风格的API是这样的:

1
2
3
4
5
6
7
8
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHander:^(NSData *data){
// Handle success
} failureHandle:^(NSError *error) {
// Handle failure
}];

这种格式非常好,因为分割了成功和失败两种情况,这意味着使用者可以将成功和失败的逻辑分开。而且,如果需要忽略成功或失败的情况,也是非常容易的。

另一种类型,是将成功情况和失败情况放进同一个block,就像这样:

1
2
3
4
5
6
7
8
#import <Foundation/Foundation.h>
@class EOCNetworkFetcher;
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data, NSError *error);
@interface EOCNetworkFetcher : NSObject
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion;
@end

使用这种风格的代码是这样的:

1
2
3
4
5
6
7
8
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHander: ^(NSData *data, NSError *error){
if (error) {
// Handle failure
} else {
// Handle success
}
}];

这种方法需要判断错误变量并需要将所有逻辑放在一个地方。它有一个坏处,因为所有的逻辑都在一个地方,这会使得这个block变得庞大和复杂。但是,这种单一block是非常灵活的。例如,传入错误的同时也能传入数据。考虑这种情况,数据下载到一半发生错误了。这种情况下,可以把数据和相关错误回传给block。这样的话,就可以处理这个问题并可能利用这些成功的数据做一些事情。

把成功和失败情况放入同一个block的另一个原因是当处理成功数据时,使用者发现了一个错误。例如,返回数据太短。这种情况可能需要按网络获取器发生错误时处理一致。如果成功和失败情况分开写在两个block里面,那这就没办法共享一份代码了,如果将方法放在别处处理,那又违反我们使用block将逻辑放在一起的概念了。

总体来说,我建议将错误情况和成功情况放在同一个block内,苹果公司似乎也是使用的这种思路。例如,Twitter框架中的TWRequestMapKit框架中的MKLocalSearch,它们都使用了单一block处理。

有时需要在某些时间点进行回调。例如,一个网络获取器的使用者可能想在每个下载进度变化时获得回调。委托也可以做到这个。不过继续使用block,你可添加一个进度处理类型的block和一个属性:

1
2
typedef void(^EOCNetworkFetcherCompletionHandler) (float progress);
@property (nonatomic, copy) EOCNetworkFetcherProgressHandler progressHandler;

这种模式很好,它将所有的逻辑放在了同一个地方:网络获取器的创建和进度block的定义。

当写处理API时,某些代码需要运行在一个确定的线程。例如,任何CocoaCocoa Touch的UI工作都必须发生在主线程。这相当于GCD中的主队列。因此,最好由API的使用者来决定它在哪条线程上运行。有这样一个API就是NSNotificationCenter,它有一个方法可以让注册者注册某个通知,等到收到通知时,就会在指定的线程执行注册好的那个block。可以给回调block指定一个线程,但不是必须的。如果没有指定线程,它就会运行在发送时的线程上。这个添加观察者的方法如下:

1
2
3
4
- (id)addObserverForName:(NSString*)name
object:(id)object
queue:(NSOperationQueue*)queue
usingBlock:(void(^)(NSNotification*))block;

这里有一个参数是使用NSOperationQueue对象指定在哪个线程上运行的。这是操作队列而不是更深层次的GCD队列,但是语义是相同的(第43节详细讲述了GCD队列与其他的区别)。

你也可以根据自己的业务去设计API,根据所需的实现细节去选择使用操作队列或者GCD队列。

小结

  • 当创建对象时,可以将逻辑以内联block的方法一并声明。
  • 当有多个网络获取器实例时,block是比委托模式更具有优势的。
  • 当设计API时如果用到了处理block,可以考虑让使用者通过一个参数去指定回调的线程。

避免block引用对象时产生循环引用

如果没有考虑清楚,block很容易产生循环引用。例如,下面的类提供一个接口,用于下载某个URL的资源。当获取器开始使用时可以设置一个回调block,当下载结束时调用block。为了在下载结束时调用block,需要将其存储在一个实例变量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// EOCNetworkFetcher.h
#import <Foundation/Foundation.h>
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
@property (nonatomic, strong, readonly) NSURL *url;
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion;
@end
// EOCNetworkFetcher.m
#import "EOCNetworkFetcher.h"
@interface EOCNetworkFetcher ()
@property (nonatomic, strong, readwrite) NSURL *url;
@property (nonatomic, copy) EOCNetworkFetcherCompletionHandler completionHandler;
@property (nonatomic, strong) NSData *downloadedData;
@end
@implementation EOCNetworkFetcher
- (id)initWithURL:(NSURL*)url {
if ((self = [super init])) {
_url = url;
}
return self;
}
- (void)startWithCompletionHandler: (EOCNetworkFetcherCompletionHandler)completion {
self.completionHandler = completion;
// Start the request
// Request sets downloadedData property
// When request is finished, p_requestCompleted is called
}
- (void)p_requestCompleted {
if (_completionHandler) {
_completionHandler(_downloadedData);
}
}
@end

另一个使用的类可能创建一个网络获取器,使用它去下载一个URL的数据,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@implementation EOCClass {
EOCNetworkFetcher *_networkFetcher;
NSData *_fetchedData;
}
- (void)downloadData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/something.dat"];
_networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[_networkFetcher startWithCompletionHandler:^(NSData *data){
NSLog(@"Request URL %@ finished", _networkFetcher.url);
_fetchedData = data;
}];
}
@end

这段代码看起来非常正常。但你可能没发现里面有一个循环引用。即完成处理block引用了self,因为它使用了_fetchedData实例变量。EOCClass的实例创建并持有了网络获取器。网络获取器持有了这个block。图6.2说明了这个循环引用。


Figure 6.2 网络获取器和类实例的循环引用。

这个循环引用可以很轻松的打破,通过令类不再引用_networkFetcher实例变量或者网络获取器类不再引用completionHandler。在这个例子中,这种破坏需要在completionHandler结束时进行,所以网络获取器是一直活跃的直到它释放。例如,completionHandlerblock可以这样修改:

1
2
3
4
5
6
[_networkFetcher startWithCompletionHandler:^(NSData *data){
NSLog(@"Request for URL %@ finished", _networkFetcher.url);
_fetchedData = data;
_networkFetcher = nil;
}

循环引用这种问题在使用完成block的API中是很常见的,所以理解它就很重要。通常,可以通过在合适时机将一方释放解决问题;但是,不是总有这种机会的。在这个例子中,是因为completionHandler运行了,循环引用才被打破。如果completionHandler永远没运行,那么循环引用永远不会打破,就会造成内存泄露。

使用completionHandlerblock还有另一种循环引用的情况。那就是当completionHandlerblock引用的对象也引用了block本身就会发生循环引用。例如,将前面的例子稍作扩展,不在运行期间保留网络获取器的引用,而是使用别的机制另其存货。那么可能会将其加入一个全局的集合,比如set集合,开始时加入其中,结束时将其移除。那么使用者可能会这样修改代码:

1
2
3
4
5
6
7
8
9
10
- (void)downloadData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/something.dat"];
EOCNetworkFetcher *networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[networkFetcher startWithCompletionHandler:^(NSData *data){
NSLog(@"Request URL %@ finished", networkFetcher.url);
_fetchedData = data;
}];
}

多数的网络库使用这种方法,因为令使用者自己保持这个对象是麻烦的。有一个例子是Twitter框架中的TWRequest类。但是,以EOCNetworkFetcher的代码来看,还是会产生循环引用。这比之前更难以发现,completionHandlerblock会使用网络获取器的url,而网络获取器又会通过completionHandler属性持有block。幸运的是,这是很容易解决的。之所以保存completionHandler属性是因为要在稍后使用它。一旦使用完毕,就不再需要持有这个block了。所以可以这样简单的解决这个问题:

1
2
3
4
5
6
- (void)p_requestCompleted {
if (_completionHandler) {
_completionHandler(_downloadedData);
}
self.completionHandler = nil;
}

一旦请求执行完毕,就可以解除循环引用了,然后获取器对象也会在需要的时候释放掉。注意将completionHandler放在start方法中是很好的主意。因为如果将completionHandler暴漏为一个公共属性,你不能在请求结束时将其清空,因为跟你将其设为公共属性的语义不符。这种情况下,只有一种方法可以打破循环引用,那就是使用者自己清空completionHandler属性。但这不是很合理,因为你不能假定一个使用者一定会去做这件事,然后它们会反过来责怪你没处理好内存泄露。

这两种循环引用的情况都很常见。当使用block时很容易产生bug。当然,如果你小心谨慎,它们也是很容易解决的。关键就是考虑清楚block会捕捉哪些对象。如果这些对象中的任意一个对象持有了block,不论是直接持有还是间接持有,都要考虑清楚如何在一个恰当时机打破循环引用。

小结

  • 当block捕捉的对象也捕捉了block时,小心循环引用这种问题。
  • 考虑一个打破循环引用的合适时机,但不要依赖API的使用者自己去打破。

多用Dispatch Queue少用同步锁

Objective-C中,如果你多个线程执行同一份代码可能会遇到一些问题。通常使用锁来解决这个问题。在GCD之前,有两个办法可以解决这个问题,第一个就是构建一个同步block:

1
2
3
4
5
- (void)synchronizedMethod {
@synchronized(self) {
// Safe
}
}

这个结构会自动创建一个基于给定对象的锁,并且直到block里面的代码执行完毕才会解锁。在block代码的最后,锁会自动解除。在这个例子中,同步行为的对象是self。这通常是一个好的选择,因为它能确保对象的每个实例可以同步的运行其同步方法。但是@synchronized(self)的过度使用会带来性能问题,使用相同同步锁的block会按顺序执行。如果你对self进行了大量的同步锁,你不得不等待一些无关紧要的代码的结束。

另一种做法是直接使用NSLock对象:

1
2
3
4
5
6
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
[_lock lock];
// Safe
[_lock unlock];
}

也可以使用NSRecursiveLock这种递归锁,它允许线程多次持有它,并且不会导致死锁。

这两种方法都是好的,但也有它们的缺点。例如,同步block会在极端情况下会导致死锁并且会影响性能。当发生死锁时,直接使用锁会非常麻烦。

对它们可以使用GCD进行替代,它可以提供一套高效和易于管理的锁。属性由于其特性经常会被开发者做成同步的,这时会将其特质设为atomic。使用atomic属性特质可以达到这个效果。或者,如果是手动书写,下面的代码是常用的:

1
2
3
4
5
6
7
8
9
10
- (NSString*)someString {
@synchronized(self) {
return _someString;
}
}
- (void)setSomeString:(NSString*)someString {
@synchronized(self) {
_someString = someString;
}
}

重申如果滥用@synchronized(self)是危险的,因为所有这样的block都会抢夺同一个锁。如果多个属性都这样做,每个block都要等它之前的运行完,这肯定不是你想要的。我们只是想令每个属性独立的同步。

顺便说下,你应该知道这只是在某种程度上确保了线程安全,它不能确保绝对安全。当然,访问的属性肯定是原子性的。当你使用属性时,你想确保得到有效的结果,但是当你多次从同一个线程获取值时,可能每次获取的结果不一样。另外的线程可能在访问时对属性进行了修改。

一个简单高效的替代同步block或者锁对象的方法是使用一个连续的同步队列。不论是读还是写都在同一个同步队列中。这样做大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NULL);
- (NSString*)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString*)someString {
dispatch_sync(_syncQueue, ^{
_someString = someString;
});
}

这个模式的思路是所有访问属性的操作都是同步的,因为GCD队列可以确保settergetter方法运行在一个连续队列上。getter中的__block语法部分,是用于允许block可以设置本地变量,这种方法是更整洁的。所有的加锁都是在GCD中处理的,GCD是一个是现在非常底层的功能,所以它有很多优化。因此,你不需要担心别的事情,只需要聚焦在你的访问器代码上即可。

但是,我们还可以尽一步优化。setter方法不一定需要是同步的。设置实例变量的block不需要返回任何内容。这个意思是你可以这样修改setter方法:

1
2
3
4
5
- (void)setSomeString:(NSString*)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}

这个简单的变化是从同步派发变为异步派发,从调用者的角度看,它可以使得setter方法执行的更快,而读和写操作仍然会按序执行。如果你测试了性能,你可能发现它是更慢的;因为异步派发需要拷贝block。如果拷贝block的时间大于执行block的时间,那么它是慢的。所以在我们简单的例子中,它是变慢了的。但是这种方法仍是一个好的候选方法,如果block中执行的是非常重的任务。

利用getter方法可以同时发生,gettersetter需要顺序执行这一点可以进行优化。此时正体现出GCD方法的好处。使用同步block或者锁是没办法轻易做到这个的。不使用连续队列,考虑当使用并发队列时会发生什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
- (NSString*)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString*)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}

按照目前的情况来看,上述代码不是同步执行的。所有的读和写操作执行在同一条队列上,但是队列是并发的,读和写可以在同时发生。我们恰恰想阻止这一点。但是,GCD有一个简单的叫做栅栏的功能,是适用于这种情况的。这个功能是一个队列栅栏:

1
2
void dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);
void dispatch_barrier_sync(dispatch_queue_t queue, dispatch_block_t block);

栅栏必须单独执行不能跟其他队列并行。它是只作用于并发队列的,因为所有的串行队列都是按顺序执行的。当一个队列发现下一个执行的是栅栏block,队列会等所有的当前block执行完毕再去执行栅栏block。当栅栏block执行完毕,队列再向下正常执行。

栅栏可以用在例子中的setter方法内。如果setter方法使用了栅栏,属性读取将同时发生,但是写只能单独执行。图6.3展示了许多读和单一写的队列。


Figure 6.3 并发队列中正常block的读和栅栏block的写。读并发执行;写单一执行,如同栅栏。

实现代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
- (NSString*)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString*)someString {
dispatch_barrier_async(_syncQueue, ^{
_someString = someString;
});
}

如果你进行性能测试,你肯定会发现它是比使用串行队列快的。你可以在setter中使用同步栅栏,它可能会更高效,原因如之前所说的一样。每一种性能优化的方法和选择都要基于你的使用场景。

小结

  • 派发队列可以用来提供同步场景,可以用来替代@synchronizedblock和NSLock对象。
  • 将同步与异步派发结合起来一样可以提供同步行为,并且也不会造成线程阻塞。
  • 并发队列和栅栏block使用同步行为更高效。

多使用GCD,少使用performSelector及其相关方法

由于Objective-C的动态派发,NSObject中有几个定义的方法,允许你调用任何方法。它们允许延迟执行方法或者指定在某个线程执行。它们是非常有用的功能;但是现在,GCD和block的出现使得它们的重要性大大减少。尽管你经常看到使用它们的代码,但我建议你清理掉它们。

这一系列方法中最基础的那个是performSelector:。它只有单一一个参数,那就是需要执行的选择器,下面是方法签名:

1
- (id)performSelector:(SEL)selector

它等价于直接调用这个方法。所以下面两行代码等价:

1
[object performSelector:@selector(selectorName)]; [object selectorName];

它看起来好像是多余的。如果它只能这样用那当然是。但是它真是的强大是可以在运行时决定运行哪个选择器。这样的动态绑定机制意味着你可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
SEL selector;
if ( /* some condition */ ) {
selector = @selector(foo);
} else if ( /* some other condition */ ) {
selector = @selector(bar);
} else {
selector = @selector(baz);
}
[object performSelector:selector];

这种代码非常灵活并且可以简化复杂的代码。另外也可以存储一个选择器,在之后的某个时候执行它。在这种情况下,不到运行时编译器无法知道它要执行哪个选择器。但是使用它的代价是,如果你在ARC环境下,编译器将会发出警告:

1
2
warning: performSelector may cause a leak because its selector
is unknown [-Warc-performSelector-leaks]

你可能没料到这点!如果你知道,你可能会理解为什么使用这些方法要小心了。这个警告信息看起来非常奇怪,为什么会担心可能发生内存泄露。最重要的是,你只不过简单的尝试去调用一个方法。这个原因是编译器不知道什么选择器会被调用,因此无法知道方法签名,返回类型甚至会不会有返回值。由于编译器无法知道方法名因此无法通过ARC的内存规则去确定返回值是否该释放。因此,ARC出于谨慎的做法就不会给他添加释放。但是,如果返回的值是一个已经被保留,这样的结果可能会导致内存泄露,。

考虑下面的代码:

1
2
3
4
5
6
7
8
9
SEL selector;
if ( /* some condition */ ) {
selector = @selector(newObject);
} else if ( /* some other condition */ ) {
selector = @selector(copy);
} else {
selector = @selector(someProperty);
}
id ret = [object performSelector:selector];

在上面的例子中有轻微的差别用于展示这个问题。在前两个选择器重,ret对象需要通过代码释放;第三个选择器则不需要。不仅在ARC下应该这样做,在MRC也应该这样做,这样才严格遵守了方法命名规范。不使用ARC(因此没有编译警告),如果第一个或第二个条件为真,ret对象需要被释放,其余不需要。这是很容易被忽略的,就算使用静态分析器也很难找到内存泄露的地方。这就是为什么要弃用performSelector相关方法的原因。

另一个原因是,这些方法只能返回对象或者void。尽管执行的选择器可能返回类型是void,但是performSelector方法返回的类型是id。如果想要返回整数或者浮点数,那么就需要复杂的类型转化,这是不安全的。由于id类型代表的是任意Objective-C对象,从技术上讲,只要返回值大小和指针大小相同即可,在32位架构下,任何类型都是32位,在64位架构下,任何类型读时64位的。如果返回值是一个C的结构体,performSelector方法就不能使用了。

performSelector还有不同的变体,可以在发送消息时传递参数:

1
2
- (id)performSelector:(SEL)selector withObject:(id)object
- (id)performSelector:(SEL)selector withObject:(id)objectA withObject:(id)objectB

例如,这个变体可以设置对象中名为value的属性值:

1
2
3
id object = /* an object with a property called 'value' */;
id newValue = /* new value for the property */;
[object performSelector:@selector(setValue:) withObject:newValue];

这个方法看起来是有用的,但它也有一个严重的限制。由于参数是id,所以传递的参数必须是一个对象。如果是整形或者浮点型,这些方法就不能用了。另外,使用这个方法的某个变体最多可以传递两个参数,即performSelector:withObject:withObject:。它没有办法执行参数多于两个的选择器。

performSelector的系统方法中另外一些方法还可以延时执行或者在另一个线程执行。这些方法中比较通用的如下:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)performSelector:(SEL)selector
withObject:(id)argument
afterDelay:(NSTimeInterval)delay
- (void)performSelector:(SEL)selector
onThread:(NSThread*)thread
withObject:(id)argument
waitUntilDone:(BOOL)wait
- (void)performSelectorOnMainThread:(SEL)selector
withObject:(id)argument
waitUntilDone:(BOOL)wait

但是,这些方法有更多的限制。例如,延后执行的方法不可以执行带有两个参数的选择器。在指定线程执行的方法也出于同样的理由限制很大。如果想使用这些方法就需要将这些参数打包在一个字典中然后再方法中将其一个一个取出来,这会增加消耗并可能带来潜在的bug。

所有的这些限制都可以通过使用其他方法解决。主要就是使用block(看第37节)。而且,performSelector系列方法带来的效果都可以使用GCD达到。延后执行可以使用dispatch_after,在另一个线程执行可以使用dispatch_syncdispatch_async

例如,去延后执行一个任务,你应该使用后面的格式:

1
2
3
4
5
6
7
8
9
// Using performSelector:withObject:afterDelay:
[self performSelector:@selector(doSomething) withObject:nil afterDelay:5.0];
// Using dispatch_after
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^(void){
[self doSomething];
});

在主线程执行任务:

1
2
3
4
5
6
7
8
// Using performSelectorOnMainThread:withObject:waitUntilDone:
[self performSelectorOnMainThread:@selector(doSomething) withObject:nil waitUntilDone:NO];
// Using dispatch_async
// (or if waitUntilDone is YES, then dispatch_sync)
dispatch_async(dispatch_get_main_queue(), ^{
[self doSomething];
});

小结

  • 使用performSelector系列方法有潜在的内存管理问题。如果没办法确定要执行哪个选择器,ARC编译器无法插入恰当的内存管理代码。
  • 使用performSelector系列方法限制非常大,例如返回类型和数字参数都无法使用。
  • 想让方法在某个线程上执行,最好使用GCD来实现。

掌握GCD和操作对象的使用时机

GCD是一种非常棒的技术,不过有时使用系统标准库会有更好的效果。要知道所使用的每个库的使用时机,如果使用了不合适的库,会使代码难以管理。

能与GCD的功能机制相媲美的很少。对于那种只执行一次的代码,使用dispatch_once(看第45节)是最好的选择。但是,对于在后台执行任务,使用GCD并不是最好的选择。一种与GCD不同但有关系的技术,叫做NSOperationQueue,允许你去将操作以NSOperation的子类的形式放入队列中,它也可以进行并发操作。它与GCD的相似不是一个巧合。操作队列出现在GCD之前,GCD是基于某些操作队列流行的理念设计的。实际上,从iOS 4和Mac OS X 10.6之后,操作队列的底层都是使用GCD实现的。

两者的第一个不同点是GCD是一个纯C的API,操作队列是一个Objective-C对象。在GCD中任务用block表示,block是一个轻量级的数据结构。另一方面,操作对象是一个Objective-C对象,因此它是更重的。这也就是说,GCD并不总是最好的选择。有时,使用对象带来的好处是远远大于它的消耗的。

使用NSBlockOperationNSOperationQueueaddOperationWithBlock:方法,操作队列的语法和GCD非常相似。这里列举了一些使用NSOperationNSOperationQueue的优点:

取消操作队列

使用操作队列是非常简单的。在运行任务之前,可以在NSOperation上调用cancel方法,该方法会设置一个内部标示符去告诉队列这个任务不需要运行了,但是它不能取消已经开始的操作队列了。另一方面,GCD没有办法取消未开始的操作队列。GCD那套架构是加入队列之后就不管了。你也可以在应用层实现取消功能,但那需要大量的代码,而操作队列已经实现了这个功能。

操作队列依赖

一个操作队列可以依赖其他多个操作队列。这样可以创建一个操作队列继承机制,即某个确定的操作队列可以在别的操作队列完成之后再执行。例如,你想从服务器下载一个文件,但是首先需要先下载一个清单文件。依赖可以让其余的下载操作队列在下载完清单文件之后再进行。如果操作队列被设置为并发执行,后续的下载可以同时执行,但是需要清单文件下载完毕。

操作队列属性的KVO

操作队列会有许多可以使用KVO观测的值,例如isCancelled去确定是否取消操作队列,isFinished去确定操作队列是否完成。如果像知道一个任务的状态或者比GCD更详细的观测,使用KVO是非常有用的。

操作队列的优先级

在一条队列中,每个操作队列都有它自己的优先级。高优先级的操作队列是要早于低优先级的。这个操作队列算法虽然是不透明的,但肯定经过认真思考。GCD没有办法直接做到类似事情。它没有任务优先级,但它可以给整条队列队列设置优先级而不是单个block。而令你自己在GCD上写调度算法,又是你所不愿意的。因此,操作队列提供的优先级设置功能是比GCD更适合的。

NSOperations也有自己的线程优先级,它决定了操作队列运行在哪条线程上。你在GCD上也可以做,但操作队列使用属性去控制是更简单的。

操作队列的重用

除非你使用系统构建的NSOperation的子类,例如NSBlockOperation,否则你必须自己创建子类。这个类可以生成正常的Objective-C对象,也可以存储你想存储的信息。对象在运行时可以使用存储的信息,也可以调用类中任意的方法。这使得它比派发队列中单一的block更强大。这些NSOperation类可以重复使用,它遵循软件开发中的不重复原则。

如你所见,有许多原因去使用操作队列而不是派发队列。操作队列提供了大多数你在开发时会用到的功能。不需要你自己去实现复杂功能,例如取消队列和队列优先级,这些操作队列都已经处理好了。

另一个API使用了操作队列而不是派发队列的是NSNotificationCenter,它有一个方法可以让注册者通过一个block而不是选择器去监听通知。这个方法原型如下:

1
2
3
4
- (id)addObserverForName:(NSString*)name
object:(id)object
queue:(NSOperationQueue*)queue
usingBlock:(void(^)(NSNotification*))block

相比于操作队列,这个方法也能通过派发队列去实现。但它并没有这样做,这个设计者只使用了高层的Objective-CAPI。在这个情况下,两者并无性能的差别。设计者可能不想使用派发队列,因为那需要使用GCD;注意block不是GCD,block并不需要依赖GCD。也可能是设计者只想保持所用的都是Objective-C功能。

你可能经常听到你应该使用高层API,仅在需要时使用底层API这种说法。我认为它说的没问题,但我并不盲从。某些功能确实高层的Objective-CAPI可以实现,但底层并不一定比他差。最好的办法还是由性能来决定。

小结

  • 在解决多线程和任务管理时,GCD并不是唯一选择。
  • Objective-C提供了一种高层次的API,它叫做操作队列,它可以实现大多数GCD能做到的事情。操作队列还做了一些复杂的操作,GCD也可以做到这些复杂操作,但是需要额外的代码。

通过dispatch group机制,根据系统资源状况来执行任务

dispatch group是一个GCD功能,它可以让你很轻易的将任务分组。你可以将任务放在一个集合中,当集合中任务执行完毕,你会收到一个回调。这个功能是非常有用的,首先是当你想执行多个并发任务,并想在它们都结束之后做些什么。例如,可以把压缩一系列文件的任务放在一个任务组中。使用下面的函数创建dispatch group

1
dispatch_group_t dispatch_group_create();

dispatch group是一个简单的数据结构,结构之间没有什么不同,它不像派发队列,派发队列是有标示符的。你有两种办法可以将任务加入组内。第一种是使用下面的函数:

1
void dispatch_group_async(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);

这是正常dispatch_async函数的一个变种,它多了一个派发组参数,它将block任务关联进组内执行。第二个将方法关联进组内的办法是下面这对函数:

1
2
void dispatch_group_enter(dispatch_group_t group);
void dispatch_group_leave(dispatch_group_t group);

第一个函数会让派发组中正要执行的任务数增加;第二个与之相反。因此,每次调用dispatch_group_enter,也必须调用dispatch_group_leave。这与引用计数(看第29节)类似,引用计数的retainrelease必须成对出现避免内存泄露。在派发组这个例子中,如果调用了一次enter而没有调用leave,那么组将永远不会结束。

下面这个函数可用来等待dispatch group执行完毕:

1
long dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);

这个函数有两个参数,一个是派发组,一个是超时值。这个超时值指定乐这个函数会等任务组执行多久。如果任务组在超时前结束,那么会返回0;否则,就会返回非0。这个值可以使用DISPATCH_TIME_FOREVER,这样会一直等待组任务执行完毕,也永远不会超时。

下面的函数可以替代上面函数的,它一样可以阻塞当前线程直到任务组执行完毕:

1
void dispatch_group_notify(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);

它们些微不同在于,这个函数可以 让你指定一个block,当组任务结束时,可以运行在某个特定的线程上。如果你不希望当前线程被阻塞,又想知道任务组什么时候结束,这是非常有用的。例如,在Mac OS X和iOS尚,你永远不该阻塞主线程,因为它是绘制UI和事件相应的线程。

如果想让数组中每个对象都执行一个任务,并等待它们执行完毕,可以使用GCD的这个功能。下面代码做到了这点:

1
2
3
4
5
6
7
8
9
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for (id object in collection) {
dispatch_group_async(dispatchGroup,
queue,
^{ [object performTask]; });
}
dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER);
// Continue processing after completing tasks

如果当前线程不该被阻塞,你应该使用通知函数替代等待函数:

1
2
3
4
5
6
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup,
notifyQueue,
^{
// Continue processing after completing tasks
});

这个回调所执行的线程应该取决于情况。这里,我使用了主队列,它是一个常用的做法。你也可以使用任意串行队列或者全局并发队列。

在这个例子中,所有任务都派发到同一个队列上。但这样做并不是必须的。你可能想将一些任务放置在高优先级但是仍是在任务结束收到通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dispatch_queue_t lowPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t highPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for (id object in lowPriorityObjects) {
dispatch_group_async(dispatchGroup,
lowPriorityQueue,
^{ [object performTask]; });
}
for (id object in highPriorityObjects) {
dispatch_group_async(dispatchGroup,
highPriorityQueue,
^{ [object performTask]; });
}
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup,
notifyQueue,
^{
// Continue processing after completing tasks
});

除了上面例子中的提交到并发队列,也可以将其提交到串行队列中。但是如果所有的任务都在痛一个串行队列,那任务组就没什么用了。因为所有的任务都是顺序执行,你只需要在最后添加一个block,它就等价于任务组的通知了:

1
2
3
4
5
6
7
dispatch_queue_t queue = dispatch_queue_create("com.effectiveobjectivec.queue", NULL);
for (id object in collection) {
dispatch_async(queue, ^{ [object performTask]; });
}
dispatch_async(queue, ^{
// Continue processing after completing tasks
});

这段代码展示了有些情况你不需要使用任务组。有时采用单个串行队列,以及异步派发也是可以达到同样效果的。

为什么我提到执行任务要基于系统资源呢?当如,如果你往后看刚才的那个并发队列的例子,它是非常清楚的。GCD会自动创建新的线程或者复用旧的线程。在并发队列中,它可以有多个线程,这意味着多个任务block并发执行。并发线程的数量取决于很多因素,但最多的是取决于系统资源。如果CPU是多核的,队列中有许多人物等待执行,那么就会产生很多线程去执行任务。通过dispatch group提供的功能可以很容易的执行多个并发任务,并在结束时收到通知。通过GCD的原生并发队列,执行并发任务将会基于系统可用资源去分配。这样开发者只需要关注商业逻辑不需要去关注处理并发任务的复杂逻辑。

上面集合循环的例子,然后每个元素执行一个任务也能通过一个GCD函数功能达到,如下:

1
void dispatch_apply(size_t iterations, dispatch_queue_t queue, void(^block)(size_t));

这个函数执行给定次数的block,每次执行都会增加传给block的数值,这个值是从0到iterations - 1。它是这样使用的:

1
2
3
4
dispatch_queue_t queue = dispatch_queue_create("com.effectiveobjectivec.queue", NULL);
dispatch_apply(10, queue, ^(size_t i){
// Perform task
});

实际上,它等价于一个0-9的for循环,像这样:

1
2
3
for (int i = 0; i < 10; i++) {
// Perform task
}

需要注意的是dispatch_apply也可以使用并发队列。如果是这样,block将会根据系统资源同时执行,就像刚才那个组任务。如果刚才例子中的集合是一个数组,它也可以使用dispatch_apply实现:

1
2
3
4
5
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(array.count, queue, ^(size_t i){
id object = array[i];
[object performTask];
});

这个例子又一次展示了dispatch group不是必须的。但是dispatch_apply会持续阻塞,直到所有任务执行完毕。因此,如果你尝试在并发队列上运行block(或者高于当前队列的一个串行队列),就会造成死锁。如果你想在后台执行任务,那么还是使用dispatch group吧。

小结

  • dispatch group用来执行集合中的所有任务。当所有任务执行完毕时,你可以选择获得通知。
  • dispatch group可以通过并发队列执行多个并发任务。在这种情况下,GCD会基于系统资源去同时处理多个任务。如果你自己实现这个功能需要大量代码。

使用dispatch_once执行只执行一次的线程代码

单例模式是Objective-C中常用到的一种模式,一般通过叫做sharedInstance的类方法,它会返回一个单一实例,而不是每次都返回新实例。sharedInstance方法的通常实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@implementation EOCClass
+ (id)sharedInstance {
static EOCClass *sharedInstance = nil;
@synchronized(self) {
if (!sharedInstance) {
sharedInstance = [[self alloc] init];
}
}
return sharedInstance;
}
@end

我发现单例模式很容易产生激烈辩论,特别是Objective-C。线程安全是辩论的主要问题。上面的代码使用同步锁使创建的单例线程安全。无论好坏,这种代码很简单,所以随处可见。

但是,GCD的一个功能使单例模式更容易实现。这个函数如下:

1
void dispatch_once(dispatch_once_t *token, dispatch_block_t block);

这个函数接受一个特殊的dispatch_once_t类型参数,我将其称为标记,此外还有一个block。这个函数确保给予一个标记,这个block会执行一次也仅仅执行一次。这个block金辉在第一次调用时执行,最重要的是,它是线程安全的。注意,每次传入的标记必须完全一致,只有这样它才会只执行一次。因此开发者经常将其声明为静态或者全局变量。

使用这个函数重写单例模式,如下:

1
2
3
4
5
6
7
8
+ (id)sharedInstance {
static EOCClass *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}

使用dispatch_once简化了代码,也是的线程安全,所以你不需要考虑加锁或者同步锁的问题。所有都在GCD底层处理了。那个标记被声明为static,因为它需要每次都是同一个值。将其定义在static区域意味着编译器会确保每次执行sharedInstance方法时都复用旧值而不是创建新的变量。

而且,dispatch_once是非常高效的。它没有使用重量级的同步机制,否则每次运行都要加锁,它使用了原子性去访问标记,用以指示代码是否已经运行。在Mac OS X10.8.2系统64位的电脑上,使用@synchronized访问sharedInstance方法比使用dispatch_once花费的时间要多两倍。

小结

  • 单一线程安全是常见的任务。GCD提供了一种易于使用的工具去达成这个目的,它就是dispatch_once函数。
  • 标记应该声明在static或者global区域中,这样每次执行时得到的标记都是相同的。

避免使用dispatch_get_current_queue

当你在使用GCD时,特别是当你派发了任务给不同的队列时,通常都会想确定当前是哪个线程在执行。例如,UI工作一直需要在Mac OS X和iOS的主线程工作,它等价于GCD的主队列。有时,看起来可能需要去确定当前代码是否执行在主线程上。通过阅读文档,你发现可以通过下面的函数获取当前线程:

1
dispatch_queue_t dispatch_get_current_queue()

它会返回当前哪个队列在执行。确实是这样,不过使用的时候需要小心。实际上,它在iOS6.0之后就被废弃了,但在Mac OS X 10.8并未废弃。尽管如此,你也应该避免在Mac OS X上使用它。

该函数有一个典型的反面教材,就是去检测当前队列是否是指定队列,用以试图避免同步时的死锁。考虑下面的代码,它使用一个同步队列去访问一个实例变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (NSString*)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString*)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}

getter方法中可能会产生一个问题,如果调用getter方法的队列是与getter方法内部是相同队列,由于dispatch_sync只会在block执行完毕时返回。但是如果目标队列是当前队列,那么block将永远不会得到执行机会,因为应该执行block的目标队列是当前队列,而当前队列又一直阻塞。像这个例子中的getter方法就是不可重入的。

看了dispatch_get_current_queue的文档后,你可能觉得可以通过检测当前队列的方法使得getter方法可以重入,如果这样,直接执行block就行了,不需要执行派发了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (NSString*)someString {
__block NSString *localSomeString;
dispatch_block_t accessorBlock = ^{
localSomeString = _someString;
};
if (dispatch_get_current_queue() == _syncQueue) {
accessorBlock();
} else {
dispatch_sync(_syncQueue, accessorBlock);
}
return localSomeString;
}

这个例子在简单环境下可以工作。但是,它仍旧是危险且容易导致死锁的。为什么?考虑下面场景的两条串行队列:

1
2
3
4
5
6
7
8
9
dispatch_queue_t queueA = dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);
dispatch_sync(queueA, ^{
dispatch_sync(queueB, ^{
dispatch_sync(queueA, ^{
// Deadlock
});
});
});

在最内部的队列A一样会造成死锁,因为它要等待最外层的dispatch_sync完成,而最外层的又不可能完成,因为它要等最内层的执行完成,这样就死锁了。现在考虑使用dispatch_get_current_queue放入相同的检测:

1
2
3
4
5
6
7
8
9
10
dispatch_sync(queueA, ^{
dispatch_sync(queueB, ^{
dispatch_block_t block = ^{ /* ... */ };
if (dispatch_get_current_queue() == queueA) {
block();
} else {
dispatch_sync(queueA, block);
}
});
});

但是这一样会导致死锁啊,因为dispatch_get_current_queue返回的是当前队列,在上面的例子中将会返回队列B。所以仍将同步执行最内层的队列A,结果就会像之前那样死锁喽。

在这个例子中,正确的解决办法是使其不可重入。而是应该确保同步队列绝不会访问属性,即不会调用someString方法。这个队列应该只用来同步属性。派发队列是非常轻的,为了使每个属性都有自己的同步队列,我们可以为其创建多个队列。

上边的例子看起来有点做死,但是还是会有别的情况导致这个问题的。队里是可以被安排进层级中的,这意味着block会在其上级队列中执行。队列的最外层是一个全局并发队列。
图6.4展示了简单的队列结构。


Figure 6.4 派发队列层级。

队列B或者队列C的block会在队列A后面执行。所以队列A、队列B、队列C的block总会分开执行。但是队列D就可能与队列A或者队列B或者队列C同时执行了,因为队列A和队列D是放在全局并发队列里面了。如果需要执行并发任务,就会依据系统资源来进行分配了,例如GCD的核数。

由于队列有层级关系,所以去检测当前队列是哪个也并没有太大作用了。例如,你确定当前block执行在队列C上面,所以你认为在队列A上面同步执行任务就是安全的。实际上,这仍然会导致死锁。

如果一个API允许你指定回调时的block的运行队列,但是它的内部使用了一个同步串行队里,如果你的目标也是这个队列,依然会造成死锁。使用API的开发者可能以为dispatch_get_current_queue返回的是调用API那个,但其实是它内部使用的那个。

为了解决这个问题,最好的办法是使用GCD提供的队列特有数据,它允许你去将任何数据以键值对的形式添加进队列中。最重要的是,如果根据指定的键找不到数据,它会沿着队列层次一直寻找,直到根队列位置。这么说可能明白怎么使用,所以看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dispatch_queue_t queueA = dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);
dispatch_set_target_queue(queueB, queueA);
static int kQueueSpecific;
CFStringRef queueSpecificValue = CFSTR("queueA");
dispatch_queue_set_specific(queueA,
&kQueueSpecific,
(void*)queueSpecificValue,
(dispatch_function_t)CFRelease);
dispatch_sync(queueB, ^{
dispatch_block_t block = ^{ NSLog(@"No deadlock!"); };
CFStringRef retrievedValue = dispatch_get_specific(&kQueueSpecific);
if (retrievedValue) {
block();
} else {
dispatch_sync(queueA, block);
}
});

在这个例子中,创建了两个队列。将队列B的目标队列设为队列A,而队列A的目标队里仍旧是默认的全局并发队列。然后给队列A设置一个队列特有值,使用下面这个函数:

1
2
3
4
void dispatch_queue_set_specific(dispatch_queue_t queue,
const void *key,
void *context,
dispatch_function_t destructor);

第一个参数是设置数据的目标队列,后面两个是参数和值。参数和值都是不透明的void指针。对于键来说,函数是按照指针来做比较的,而不是内容。所以,队列特有数据的行为是不同于NSDictionary对象的,字典对面是对比键的内容。队列特有数据的行为更像是关联对象。值也是不透明的void指针,所以你可以放置任何值。但是,你必须去管理这个对象的内存。这使得ARC很难去自动管理这个对象的内存。在这个例子中,这个value值是一个CoreFoundation字符串,而ARC不关心任何CoreFoundation对面的内存管理。这样的对象更适合做队列特有数据,因为它可以使用免费桥与Objective-C相关类进行转换。

最后的参数是这个函数的析构函数,当对象持有的键被移除时,它就会运行。因为那时肯定是队列被释放或者有新的键值对被设置。dispatch_function_t的类型是这样定义的:

1
typedef void (*dispatch_function_t)(void*)

析构函数必须只能带有一个单一指针并且返回值一定是void。在这个例子中,使用了CFRelease,它就是这样一个函数,不过你也可以自己定义一个函数,在其中调用CFRelease,并对其它需要清理的内容进行清理。

队列特有数据提供的这套简单易用的功能避免了使用dispatch_get_current_queue所带来的坑。另外还可能在调试时使用dispatch_get_current_queue。在这种情况下,它是可以安全使用的,只要你不讲代码编译进发布版本。如果对访问当前队列有特殊需要,而当前函数又无法完成,那么最好的解决办法还是去找苹果公司吧。

小结

  • dispatch_get_current_queue函数可能不会像你想象的那么有用。它已经被废弃并且现在只应在调试时使用它。
  • 派发队列是有层级结构的;因此,单个队列无法描述当前队列。
  • 队列特有数据可以解决使用dispatch_get_current_queue遇到的常见问题,它可以避免因为无法重入导致的代码死锁。